23.05.30
오늘 한 일
- 알고리즘 문제 풀이
- Nest.js 강의 무조건 듣기
- 블로그 글 쓰기
- 카공실록 바텀시트 구현
-
함수형 프로그래밍 스터디 진행
카공실록 프로젝트
바텀시트 컴포넌트 구현
react-modal-sheet
라이브러리를 사용해서 바텀시트를 구현했다.
추가적으로 커스텀해야되는 부분이 있어서 따로 BottomSheet
컴포넌트로 분리했다.
import { useOnClickOutside } from '@/hooks/useOnClickOutside';
import { forwardRef, useRef } from 'react';
import Sheet from 'react-modal-sheet';
import type { SheetRef } from 'react-modal-sheet';
import type { SheetProps } from 'react-modal-sheet/dist/types';
interface BottomSheetProps extends SheetProps {
className?: string;
hasBackDropOpacity?: boolean;
}
function BottomSheet(
{ isOpen, onClose, children, className, hasBackDropOpacity = false, ...props }: BottomSheetProps,
ref: React.ForwardedRef<SheetRef | undefined | null>
) {
const containerRef = useRef<HTMLDivElement>(null);
useOnClickOutside(containerRef, onClose);
return (
<Sheet
ref={ref}
isOpen={isOpen}
className={`mx-auto w-full min-w-[360px] max-w-[448px] ${className ?? ''}`}
onClose={onClose}
{...props}
>
<Sheet.Container ref={containerRef}>
<Sheet.Header>
<div className="flex h-4 items-center justify-center">
<div className="h-1 w-10 rounded-full bg-bk10" />
</div>
</Sheet.Header>
<Sheet.Content className="scrollbar-hide">{children}</Sheet.Content>
</Sheet.Container>
<Sheet.Backdrop
className={`${hasBackDropOpacity ? '!bg-[rgba(0,0,0,0.6)]' : '!bg-[rgba(0,0,0,0)]'}`}
/>
</Sheet>
);
}
export default forwardRef(BottomSheet);
바텀시트의 밖을 클릭하면 닫히게 하기 위해 useOnClickOutside
훅을 만들었다.
import { useEffect } from 'react';
export function useOnClickOutside<T extends HTMLElement = HTMLElement>(
ref: React.RefObject<T>,
handler: (event: MouseEvent | TouchEvent) => void
) {
useEffect(() => {
const listener = (event: MouseEvent | TouchEvent) => {
event.stopPropagation();
if (!ref.current || ref.current.contains(event.target as Node)) {
return;
}
handler(event);
};
document.addEventListener('mousedown', listener);
document.addEventListener('touchstart', listener);
return () => {
document.removeEventListener('mousedown', listener);
document.removeEventListener('touchstart', listener);
};
}, [ref, handler]);
}
추가로 클래스를 커스텀해서 디자인에 맞게 바텀시트를 꾸몄다.
.react-modal-sheet-backdrop {
min-width: 360px;
max-width: 448px;
left: 50% !important;
transform: translateX(-50%);
}
.react-modal-sheet-container {
border-radius: 0 !important;
}
이렇게 PR을 올렸다.
바텀시트 적용
리뷰 작성하기 버튼을 클릭하면 바텀시트가 올라와야한다.
처음 올라올 때는 평가하는 이모티콘 5개가 보여야하고, 하나를 클릭하면 바텀시트가 위로 올라가면서 리뷰 작성 폼이 보여야한다.
이렇게 구현하기 위해 react-modal-sheet
라이브러리에서 제공하는 props를 사용했다.
<BottomSheet
ref={ref}
isOpen={isOpen}
onClose={onClose}
snapPoints={[0.9, 280]}
initialSnap={1}
hasBackDropOpacity={true}
>
...
</BottomSheet>
initialSnap
으로 처음에는 280px이 올라오고, 이모티콘을 클릭하면 onClick으로 ref.current?.snapTo(0)
을 호출해서 0.9로 올라오게 했다. 여기서 0.9는 90%를 의미한다.
const ref = useRef<SheetRef>(null);
const onClick = () => {
ref.current?.snapTo(0);
};
문제점 1
디자인에서는 바텀시트가 최대로 올라오는 높이가 맨 위로부터 70px을 요구했다. 하지만 snapPoints
에 들어가는 값들은 바텀시트의 높이를 의미하기 때문에 70px을 넣을 수 없었다.
라이브러리가 어떻게 동작하는지 확인해보았다.
const SheetContainer = React.forwardRef<any, SheetContainerProps>(
({ children, style = {}, className = '', ...rest }, ref) => {
// ...
const initialY = snapPoints ? snapPoints[0] - snapPoints[initialSnap] : 0;
const maxSnapHeight = snapPoints ? snapPoints[0] : null;
const height = maxSnapHeight !== null ? `min(${maxSnapHeight}px, ${MAX_HEIGHT})` : MAX_HEIGHT;
return (
<motion.div
{...rest}
ref={mergeRefs([sheetRef, ref])}
className={`react-modal-sheet-container ${className}`}
style={{
...styles.container,
...style,
...(detent === 'full-height' && { height }),
...(detent === 'content-height' && { maxHeight: height }),
y,
}}
// ...
>
{children}
</motion.div>
);
}
);
여기서 height
를 계산하는 부분을 보면 snapPoints
의 첫번째 값이 maxSnapHeight
로 들어가고, maxSnapHeight
가 null
이 아니면 min(${maxSnapHeight}px, ${MAX_HEIGHT})
로 계산된다.
MAX_HEIGHT는 아래처럼 정의되어있다.
export const MAX_HEIGHT = 'calc(100% - env(safe-area-inset-top) - 34px)';
env(safe-area-inset-top)
은 아이폰 상단 노치의 높이를 의미한다.
그리고 34px은 바텀시트의 상단과 하단의 패딩을 의미한다.
maxSnapHeight로 들어가야할 값이 윈도우의 높이 - 70px
이면 된다. 그래서 snapPoints
에 들어가는 값들을 아래처럼 계산했다.
window.innerHeight
를 쓰면 윈도우 높이를 구할 수 있는데, 창의 크기가 변하면 window.innerHeight
의 값도 변하기 때문에 hook으로 만들어서 사용했다.
react-modal-sheet
도 해당 훅을 만들어서 사용하고 있어서 그대로 가져와서 사용했다.
import { useIsomorphicLayoutEffect } from 'framer-motion';
import { useState } from 'react';
export function useWindowHeight() {
const [windowHeight, setWindowHeight] = useState(0);
useIsomorphicLayoutEffect(() => {
const updateHeight = () => setWindowHeight(window.innerHeight);
window.addEventListener('resize', updateHeight);
updateHeight();
return () => window.removeEventListener('resize', updateHeight);
}, []);
return windowHeight;
}
적용 결과
const windowHeight = useWindowHeight();
return (
<BottomSheet
ref={ref}
isOpen={isOpen}
onClose={onClose}
snapPoints={[windowHeight - 70, 280]}
initialSnap={1}
hasBackDropOpacity={true}
>
...
</BottomSheet>
);
TIL 쓰다가 알게 됐는데, 이렇게 안해도 그냥
snapPoints={[-70, 280]}
으로 해도 된다...
그리고useOnClickOutside
안쓰고 그냥 Backdrop 컴포넌트에 props로onTap={onClose}
넣어주면 된다... 하하하하...헣
문제점 2
디자인에서는 리뷰등록 버튼이 바텀시트 하단에 고정되어있다.
근데 react-modal-sheet
에서는 snapPoints
에 들어가는 값들 중 제일 큰 값이 바텀시트의 content 높이를 의미하기 때문에, 리뷰등록 버튼이 바텀시트 하단에 고정되지 않는다.
이 부분은 아직 고민 중이다. 여러가지 방법을 생각해봤는데, 아직 해결하지 못했다.
문제점 3
처음 바텀시트가 올라올 때 이모티콘 선택하는 것만 보이고 나머지가 보이지 않게 해야한다. 그럴려면 스크롤락을 걸어야하는데 잘 안된다. 다음에 다시 해보자
내일 할 일
- 알고리즘 문제 풀이
- 블로그 글 업로드
- GDG 컨퍼런스
2023프론트엔드 트렌드 따라잡기
참여 - 알고리즘 시험 공부